[id].vue 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234
  1. <template>
  2. <div v-if="workflow" class="flex h-screen">
  3. <div
  4. v-if="state.showSidebar && haveEditAccess"
  5. class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
  6. >
  7. <workflow-edit-block
  8. v-if="editState.editing"
  9. :data="editState.blockData"
  10. :workflow="workflow"
  11. :editor="editor"
  12. @update="updateBlockData"
  13. @close="(editState.editing = false), (editState.blockData = {})"
  14. />
  15. <workflow-details-card
  16. v-else
  17. :workflow="workflow"
  18. @update="updateWorkflow"
  19. />
  20. </div>
  21. <div class="flex-1 relative overflow-auto">
  22. <div
  23. class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
  24. >
  25. <ui-card
  26. v-if="!haveEditAccess"
  27. padding="px-2 mr-4"
  28. class="flex items-center overflow-hidden"
  29. style="min-width: 150px; height: 48px"
  30. >
  31. <span class="inline-block">
  32. <ui-img
  33. v-if="workflow.icon.startsWith('http')"
  34. :src="workflow.icon"
  35. class="w-8 h-8"
  36. />
  37. <v-remixicon v-else :name="workflow.icon" size="26" />
  38. </span>
  39. <div class="ml-2 max-w-sm">
  40. <p
  41. :class="{ 'text-lg': !workflow.description }"
  42. class="font-semibold leading-tight text-overflow"
  43. >
  44. {{ workflow.name }}
  45. </p>
  46. <p
  47. :class="{ 'text-sm': workflow.description }"
  48. class="text-gray-600 leading-tight dark:text-gray-200 text-overflow"
  49. >
  50. {{ workflow.description }}
  51. </p>
  52. </div>
  53. </ui-card>
  54. <ui-tabs
  55. v-model="state.activeTab"
  56. class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
  57. >
  58. <button
  59. v-if="haveEditAccess"
  60. v-tooltip="
  61. `${t('workflow.toggleSidebar')} (${
  62. shortcut['editor:toggle-sidebar'].readable
  63. })`
  64. "
  65. style="margin-right: 6px"
  66. @click="toggleSidebar"
  67. >
  68. <v-remixicon
  69. :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
  70. />
  71. </button>
  72. <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
  73. <ui-tab value="logs" class="flex items-center">
  74. {{ t('common.log', 2) }}
  75. <span
  76. v-if="workflowStates.length > 0"
  77. class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
  78. style="min-width: 25px"
  79. >
  80. {{ workflowStates.length }}
  81. </span>
  82. </ui-tab>
  83. </ui-tabs>
  84. <ui-card v-if="isTeamWorkflow" padding="p-1 ml-4 pointer-events-auto">
  85. <ui-input
  86. v-tooltip="'Workflow URL'"
  87. prepend-icon="riLinkM"
  88. :model-value="`https://automa.site/teams/${teamId}/workflows/${workflow.id}`"
  89. readonly
  90. @click="$event.target.select()"
  91. />
  92. </ui-card>
  93. <div class="flex-grow pointer-events-none" />
  94. <editor-used-credentials v-if="editor" :editor="editor" />
  95. <editor-local-actions
  96. :editor="editor"
  97. :workflow="workflow"
  98. :is-data-changed="state.dataChanged"
  99. :is-team="isTeamWorkflow"
  100. :can-edit="haveEditAccess"
  101. @update="onActionUpdated"
  102. @permission="checkWorkflowPermission"
  103. @modal="(modalState.name = $event), (modalState.show = true)"
  104. />
  105. </div>
  106. <ui-tab-panels
  107. v-model="state.activeTab"
  108. class="overflow-hidden h-full w-full"
  109. @drop="onDropInEditor"
  110. @dragend="clearHighlightedElements"
  111. @dragover.prevent="onDragoverEditor"
  112. >
  113. <ui-tab-panel cache value="editor" class="w-full">
  114. <workflow-editor
  115. v-if="state.workflowConverted"
  116. :id="route.params.id"
  117. :data="workflow.drawflow"
  118. :disabled="isTeamWorkflow && !haveEditAccess"
  119. :class="{ 'animate-blocks': state.animateBlocks }"
  120. class="h-screen"
  121. @init="onEditorInit"
  122. @edit="initEditBlock"
  123. @update:node="state.dataChanged = true"
  124. @delete:node="state.dataChanged = true"
  125. >
  126. <template v-if="!isTeamWorkflow || haveEditAccess" #controls-append>
  127. <button
  128. v-tooltip="t('workflow.autoAlign.title')"
  129. class="control-button hoverable ml-2"
  130. @click="autoAlign"
  131. >
  132. <v-remixicon name="riMagicLine" />
  133. </button>
  134. <ui-card padding="p-0 ml-2 undo-redo">
  135. <button
  136. v-tooltip.group="
  137. `${t('workflow.undo')} (${getReadableShortcut('mod+z')})`
  138. "
  139. :disabled="!commandManager.state.value.canUndo"
  140. class="p-2 rounded-lg transition-colors"
  141. @click="executeCommand('undo')"
  142. >
  143. <v-remixicon name="riArrowGoBackLine" />
  144. </button>
  145. <button
  146. v-tooltip.group="
  147. `${t('workflow.redo')} (${getReadableShortcut(
  148. 'mod+shift+z'
  149. )})`
  150. "
  151. :disabled="!commandManager.state.value.canRedo"
  152. class="p-2 rounded-lg transition-colors"
  153. @click="executeCommand('redo')"
  154. >
  155. <v-remixicon name="riArrowGoForwardLine" />
  156. </button>
  157. </ui-card>
  158. </template>
  159. </workflow-editor>
  160. <editor-local-ctx-menu
  161. v-if="editor"
  162. :editor="editor"
  163. @copy="copySelectedElements"
  164. @paste="pasteCopiedElements"
  165. @duplicate="duplicateElements"
  166. />
  167. </ui-tab-panel>
  168. <ui-tab-panel value="logs" class="mt-24 container">
  169. <editor-logs
  170. :workflow-id="route.params.id"
  171. :workflow-states="workflowStates"
  172. />
  173. </ui-tab-panel>
  174. </ui-tab-panels>
  175. </div>
  176. </div>
  177. <ui-modal
  178. v-model="modalState.show"
  179. :content-class="activeWorkflowModal?.width || 'max-w-xl'"
  180. v-bind="activeWorkflowModal.attrs || {}"
  181. >
  182. <template v-if="activeWorkflowModal.title" #header>
  183. {{ activeWorkflowModal.title }}
  184. <a
  185. v-if="activeWorkflowModal.docs"
  186. :title="t('common.docs')"
  187. :href="activeWorkflowModal.docs"
  188. target="_blank"
  189. class="inline-block align-middle"
  190. >
  191. <v-remixicon name="riInformationLine" size="20" />
  192. </a>
  193. </template>
  194. <component
  195. :is="activeWorkflowModal.component"
  196. v-bind="{ workflow }"
  197. v-on="activeWorkflowModal?.events || {}"
  198. @update="updateWorkflow"
  199. @close="modalState.show = false"
  200. />
  201. </ui-modal>
  202. <shared-permissions-modal
  203. v-model="permissionState.showModal"
  204. :permissions="permissionState.items"
  205. @granted="registerTrigger"
  206. />
  207. </template>
  208. <script setup>
  209. import {
  210. watch,
  211. provide,
  212. reactive,
  213. computed,
  214. onMounted,
  215. shallowRef,
  216. onBeforeUnmount,
  217. } from 'vue';
  218. import cloneDeep from 'lodash.clonedeep';
  219. import { useI18n } from 'vue-i18n';
  220. import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
  221. import { customAlphabet } from 'nanoid';
  222. import { useToast } from 'vue-toastification';
  223. import defu from 'defu';
  224. import dagre from 'dagre';
  225. import { useUserStore } from '@/stores/user';
  226. import { useWorkflowStore } from '@/stores/workflow';
  227. import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
  228. import {
  229. useShortcut,
  230. getShortcut,
  231. getReadableShortcut,
  232. } from '@/composable/shortcut';
  233. import { getWorkflowPermissions } from '@/utils/workflowData';
  234. import { tasks } from '@/utils/shared';
  235. import { fetchApi } from '@/utils/api';
  236. import { functions } from '@/utils/referenceData/mustacheReplacer';
  237. import { useGroupTooltip } from '@/composable/groupTooltip';
  238. import { useCommandManager } from '@/composable/commandManager';
  239. import { debounce, parseJSON, throttle } from '@/utils/helper';
  240. import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
  241. import browser from 'webextension-polyfill';
  242. import dbStorage from '@/db/storage';
  243. import DroppedNode from '@/utils/editor/DroppedNode';
  244. import EditorCommands from '@/utils/editor/EditorCommands';
  245. import convertWorkflowData from '@/utils/convertWorkflowData';
  246. import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
  247. import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
  248. import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
  249. import WorkflowShareTeam from '@/components/newtab/workflow/WorkflowShareTeam.vue';
  250. import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
  251. import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
  252. import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
  253. import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
  254. import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
  255. import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
  256. import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
  257. import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
  258. import EditorUsedCredentials from '@/components/newtab/workflow/editor/EditorUsedCredentials.vue';
  259. let editorCommands = null;
  260. const executeCommandTimeout = null;
  261. const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 7);
  262. useGroupTooltip();
  263. const { t } = useI18n();
  264. const toast = useToast();
  265. const route = useRoute();
  266. const router = useRouter();
  267. const userStore = useUserStore();
  268. const workflowStore = useWorkflowStore();
  269. const commandManager = useCommandManager();
  270. const teamWorkflowStore = useTeamWorkflowStore();
  271. const { teamId, id: workflowId } = route.params;
  272. const isTeamWorkflow = route.name === 'team-workflows';
  273. const editor = shallowRef(null);
  274. const connectedTable = shallowRef(null);
  275. const state = reactive({
  276. showSidebar: true,
  277. dataChanged: false,
  278. animateBlocks: false,
  279. isExecuteCommand: false,
  280. workflowConverted: false,
  281. activeTab: route.query.tab || 'editor',
  282. });
  283. const permissionState = reactive({
  284. permissions: [],
  285. showModal: false,
  286. });
  287. const modalState = reactive({
  288. name: '',
  289. show: false,
  290. });
  291. const editState = reactive({
  292. blockData: {},
  293. editing: false,
  294. });
  295. const autocompleteState = reactive({
  296. blocks: {},
  297. common: {},
  298. });
  299. const workflowPayload = {
  300. data: {},
  301. isUpdating: false,
  302. };
  303. const workflowModals = {
  304. table: {
  305. icon: 'riKey2Line',
  306. width: 'max-w-2xl',
  307. component: WorkflowDataTable,
  308. title: t('workflow.table.title'),
  309. docs: 'https://docs.automa.site/api-reference/table.html',
  310. events: {
  311. /* eslint-disable-next-line */
  312. connect: fetchConnectedTable,
  313. disconnect() {
  314. connectedTable.value = null;
  315. },
  316. },
  317. },
  318. 'workflow-share': {
  319. icon: 'riShareLine',
  320. component: WorkflowShare,
  321. attrs: {
  322. blur: true,
  323. persist: true,
  324. customContent: true,
  325. },
  326. events: {
  327. close() {
  328. modalState.show = false;
  329. modalState.name = '';
  330. },
  331. publish() {
  332. modalState.show = false;
  333. modalState.name = '';
  334. },
  335. },
  336. },
  337. 'workflow-share-team': {
  338. icon: 'riShareLine',
  339. component: WorkflowShareTeam,
  340. attrs: {
  341. blur: true,
  342. persist: true,
  343. customContent: true,
  344. },
  345. events: {
  346. close() {
  347. modalState.show = false;
  348. modalState.name = '';
  349. },
  350. publish() {
  351. modalState.show = false;
  352. modalState.name = '';
  353. },
  354. },
  355. },
  356. 'global-data': {
  357. width: 'max-w-2xl',
  358. icon: 'riDatabase2Line',
  359. component: WorkflowGlobalData,
  360. title: t('common.globalData'),
  361. docs: 'https://docs.automa.site/api-reference/global-data.html',
  362. },
  363. settings: {
  364. width: 'max-w-2xl',
  365. icon: 'riSettings3Line',
  366. component: WorkflowSettings,
  367. title: t('common.settings'),
  368. attrs: {
  369. customContent: true,
  370. },
  371. events: {
  372. close() {
  373. modalState.show = false;
  374. modalState.name = '';
  375. },
  376. },
  377. },
  378. };
  379. const autocompleteKeys = {
  380. loopId: 'loopData',
  381. refKey: 'googleSheets',
  382. variableName: 'variables',
  383. };
  384. const autocompleteList = computed(() => {
  385. const autocompleteData = {
  386. loopData: {},
  387. googleSheets: {},
  388. table: {},
  389. ...Object.keys(functions),
  390. globalData: autocompleteState.common.globalData,
  391. variables: { ...autocompleteState.common.variables },
  392. };
  393. Object.values(autocompleteState.blocks).forEach((item) => {
  394. Object.keys(item).forEach((key) => {
  395. const autocompleteKey = autocompleteKeys[key] || [];
  396. autocompleteData[autocompleteKey][item[key]] = '';
  397. });
  398. });
  399. return autocompleteData;
  400. });
  401. const haveEditAccess = computed(() => {
  402. if (!isTeamWorkflow) return true;
  403. return userStore.validateTeamAccess(teamId, ['edit', 'owner', 'create']);
  404. });
  405. const workflow = computed(() => {
  406. if (isTeamWorkflow) {
  407. return teamWorkflowStore.getById(teamId, workflowId);
  408. }
  409. return workflowStore.getById(workflowId);
  410. });
  411. const workflowStates = computed(() =>
  412. workflowStore.getWorkflowStates(route.params.id)
  413. );
  414. const activeWorkflowModal = computed(
  415. () => workflowModals[modalState.name] || {}
  416. );
  417. const workflowColumns = computed(() => {
  418. if (connectedTable.value) {
  419. return connectedTable.value.columns;
  420. }
  421. return workflow.value.table;
  422. });
  423. provide('workflow', {
  424. editState,
  425. data: workflow,
  426. columns: workflowColumns,
  427. });
  428. provide('workflow-editor', editor);
  429. provide('autocompleteData', autocompleteList);
  430. const updateBlockData = debounce((data) => {
  431. if (!haveEditAccess.value) return;
  432. const node = editor.value.getNode.value(editState.blockData.blockId);
  433. const dataCopy = JSON.parse(JSON.stringify(data));
  434. let autocompleteId = '';
  435. if (editState.blockData.itemId) {
  436. const itemIndex = node.data.blocks.findIndex(
  437. ({ itemId }) => itemId === editState.blockData.itemId
  438. );
  439. if (itemIndex !== -1) {
  440. node.data.blocks[itemIndex].data = dataCopy;
  441. autocompleteId = editState.blockData.itemId;
  442. }
  443. } else {
  444. node.data = dataCopy;
  445. autocompleteId = editState.blockData.blockId;
  446. }
  447. if (autocompleteState.blocks[autocompleteId]) {
  448. const { id, blockId } = editState.blockData;
  449. Object.assign(
  450. autocompleteState.blocks,
  451. /* eslint-disable-next-line */
  452. extractAutocopmleteData(id, { data, id: blockId })
  453. );
  454. }
  455. editState.blockData.data = data;
  456. state.dataChanged = true;
  457. }, 250);
  458. const updateHostedWorkflow = throttle(async () => {
  459. if (isTeamWorkflow) return;
  460. if (!userStore.user || workflowPayload.isUpdating) return;
  461. const isHosted = userStore.hostedWorkflows[route.params.id];
  462. const isBackup = userStore.backupIds.includes(route.params.id);
  463. const workflowExist = workflowStore.getById(route.params.id);
  464. if (
  465. (!isBackup && !isHosted) ||
  466. !workflowExist ||
  467. Object.keys(workflowPayload.data).length === 0
  468. )
  469. return;
  470. workflowPayload.isUpdating = true;
  471. const delKeys = [
  472. 'id',
  473. 'pass',
  474. 'logs',
  475. 'trigger',
  476. 'createdAt',
  477. 'isDisabled',
  478. 'isProtected',
  479. ];
  480. delKeys.forEach((key) => {
  481. delete workflowPayload.data[key];
  482. });
  483. try {
  484. if (typeof workflowPayload.data.drawflow === 'string') {
  485. workflowPayload.data.drawflow = parseJSON(
  486. workflowPayload.data.drawflow,
  487. workflowPayload.data.drawflow
  488. );
  489. }
  490. const response = await fetchApi(`/me/workflows/${route.params.id}`, {
  491. method: 'PUT',
  492. keepalive: true,
  493. body: JSON.stringify({
  494. workflow: workflowPayload.data,
  495. }),
  496. });
  497. if (!response.ok) throw new Error(response.message);
  498. if (isBackup) {
  499. const result = await response.json();
  500. if (result.updatedAt) {
  501. await browser.storage.local.set({ lastBackup: result.updatedAt });
  502. }
  503. }
  504. workflowPayload.data = {};
  505. workflowPayload.isUpdating = false;
  506. } catch (error) {
  507. console.error(error);
  508. workflowPayload.isUpdating = false;
  509. }
  510. }, 5000);
  511. const onEdgesChange = debounce((changes) => {
  512. // const edgeChanges = { added: [], removed: [] };
  513. changes.forEach(({ type }) => {
  514. // if (type === 'remove') {
  515. // edgeChanges.removed.push(id);
  516. // } else if (type === 'add') {
  517. // edgeChanges.added.push(item);
  518. // }
  519. if (state.dataChanged) return;
  520. state.dataChanged = type !== 'select';
  521. });
  522. // if (state.isExecuteCommand) return;
  523. // let command = null;
  524. // if (edgeChanges.added.length > 0) {
  525. // command = editorCommands.edgeAdded(edgeChanges.added);
  526. // } else if (edgeChanges.removed.length > 0) {
  527. // command = editorCommands.edgeRemoved(edgeChanges.removed);
  528. // }
  529. // if (command) commandManager.add(command);
  530. }, 250);
  531. function extractAutocopmleteData(label, { data, id }) {
  532. const autocompleteData = { [id]: {} };
  533. const getData = (blockName, blockData) => {
  534. const keys = tasks[blockName]?.autocomplete;
  535. const dataList = {};
  536. if (!keys) return dataList;
  537. keys.forEach((key) => {
  538. const value = blockData[key];
  539. if (!value) return;
  540. dataList[key] = value;
  541. });
  542. return dataList;
  543. };
  544. if (label === 'blocks-group') {
  545. data.blocks.forEach((block) => {
  546. autocompleteData[block.itemId] = getData(block.id, block.data);
  547. });
  548. } else {
  549. autocompleteData[id] = getData(label, data);
  550. }
  551. return autocompleteData;
  552. }
  553. async function initAutocomplete() {
  554. const autocompleteCache = sessionStorage.getItem(
  555. `autocomplete:${workflowId}`
  556. );
  557. if (autocompleteCache) {
  558. const objData = parseJSON(autocompleteCache, {});
  559. autocompleteState.blocks = objData;
  560. } else {
  561. const autocompleteData = {};
  562. workflow.value.drawflow.nodes.forEach(({ label, id, data }) => {
  563. Object.assign(
  564. autocompleteData,
  565. extractAutocopmleteData(label, { data, id })
  566. );
  567. });
  568. autocompleteState.blocks = autocompleteData;
  569. }
  570. try {
  571. const storageVars = await dbStorage.variables.toArray();
  572. autocompleteState.common.globalData = parseJSON(
  573. workflow.value.globalData,
  574. {}
  575. );
  576. autocompleteState.common.variables = {};
  577. storageVars.forEach((variable) => {
  578. autocompleteState.common.variables[`$$${variable.name}`] = {};
  579. });
  580. } catch (error) {
  581. console.error(error);
  582. }
  583. }
  584. function registerTrigger() {
  585. const triggerBlock = workflow.value.drawflow.nodes.find(
  586. (node) => node.label === 'trigger'
  587. );
  588. registerWorkflowTrigger(workflowId, triggerBlock);
  589. }
  590. function executeCommand(type) {
  591. state.isExecuteCommand = true;
  592. if (type === 'undo') {
  593. commandManager.undo();
  594. } else if (type === 'redo') {
  595. commandManager.redo();
  596. }
  597. clearTimeout(executeCommandTimeout);
  598. setTimeout(() => {
  599. state.isExecuteCommand = false;
  600. }, 500);
  601. }
  602. function onNodesChange(changes) {
  603. const nodeChanges = { added: [], removed: [] };
  604. changes.forEach(({ type, id, item }) => {
  605. if (type === 'remove') {
  606. if (editState.blockData.blockId === id) {
  607. editState.editing = false;
  608. editState.blockData = {};
  609. }
  610. state.dataChanged = true;
  611. nodeChanges.removed.push(id);
  612. } else if (type === 'add') {
  613. nodeChanges.added.push(item);
  614. }
  615. });
  616. if (state.isExecuteCommand) return;
  617. let command = null;
  618. if (nodeChanges.added.length > 0) {
  619. command = editorCommands.nodeAdded(nodeChanges.added);
  620. } else if (nodeChanges.removed.length > 0) {
  621. command = editorCommands.nodeRemoved(nodeChanges.removed);
  622. }
  623. if (command) {
  624. commandManager.add(command);
  625. }
  626. }
  627. function autoAlign() {
  628. state.animateBlocks = true;
  629. const graph = new dagre.graphlib.Graph();
  630. graph.setGraph({
  631. rankdir: 'LR',
  632. ranksep: 100,
  633. ranker: 'tight-tree',
  634. });
  635. graph._isMultigraph = true;
  636. graph.setDefaultEdgeLabel(() => ({}));
  637. editor.value.getNodes.value.forEach(
  638. ({ id, label, dimensions, parentNode }) => {
  639. if (label === 'blocks-group-2' || parentNode) return;
  640. graph.setNode(id, {
  641. label,
  642. width: dimensions.width,
  643. height: dimensions.height,
  644. });
  645. }
  646. );
  647. editor.value.getEdges.value.forEach(({ source, target, id }) => {
  648. graph.setEdge(source, target, { id });
  649. });
  650. dagre.layout(graph);
  651. const nodeChanges = [];
  652. graph.nodes().forEach((nodeId) => {
  653. const graphNode = graph.node(nodeId);
  654. if (!graphNode) return;
  655. const { x, y } = graphNode;
  656. if (editorCommands.state.nodes[nodeId]) {
  657. editorCommands.state.nodes[nodeId].position = { x, y };
  658. }
  659. nodeChanges.push({
  660. id: nodeId,
  661. type: 'position',
  662. dragging: false,
  663. position: { x, y },
  664. });
  665. });
  666. editor.value.applyNodeChanges(nodeChanges);
  667. editor.value.fitView();
  668. setTimeout(() => {
  669. state.dataChanged = true;
  670. state.animateBlocks = false;
  671. }, 500);
  672. }
  673. function toggleSidebar() {
  674. state.showSidebar = !state.showSidebar;
  675. localStorage.setItem('workflow:sidebar', state.showSidebar);
  676. }
  677. function initEditBlock(data) {
  678. const { editComponent, data: blockDefData } = tasks[data.id];
  679. const blockData = defu(data.data, blockDefData);
  680. editState.blockData = { ...data, editComponent, data: blockData };
  681. if (data.id === 'wait-connections') {
  682. const connections = editor.value.getEdges.value.reduce(
  683. (acc, { target, sourceNode, source }) => {
  684. if (target !== data.blockId) return acc;
  685. let name = t(`workflow.blocks.${sourceNode.label}.name`);
  686. const { description } = sourceNode.data;
  687. if (description) name += ` (${description})`;
  688. acc.push({
  689. name,
  690. id: source,
  691. });
  692. return acc;
  693. },
  694. []
  695. );
  696. editState.blockData.connections = connections;
  697. }
  698. editState.editing = true;
  699. }
  700. async function updateWorkflow(data) {
  701. try {
  702. if (isTeamWorkflow) {
  703. if (!haveEditAccess.value && !data.globalData) return;
  704. await teamWorkflowStore.update({
  705. data,
  706. teamId,
  707. id: workflowId,
  708. });
  709. } else {
  710. await workflowStore.update({
  711. data,
  712. id: route.params.id,
  713. });
  714. }
  715. workflowPayload.data = { ...workflowPayload.data, ...data };
  716. if (!isTeamWorkflow) await updateHostedWorkflow();
  717. } catch (error) {
  718. console.error(error);
  719. }
  720. }
  721. function onActionUpdated({ data, changedIndicator }) {
  722. state.dataChanged = changedIndicator;
  723. workflowPayload.data = { ...workflowPayload.data, ...data };
  724. updateHostedWorkflow();
  725. }
  726. function onEditorInit(instance) {
  727. editor.value = instance;
  728. instance.onEdgesChange(onEdgesChange);
  729. instance.onNodesChange(onNodesChange);
  730. instance.onEdgeDoubleClick(({ edge }) => {
  731. instance.removeEdges([edge]);
  732. });
  733. // instance.onEdgeUpdateEnd(({ edge }) => {
  734. // editorCommands.state.edges[edge.id] = edge;
  735. // });
  736. instance.onNodeDragStop(({ nodes }) => {
  737. nodes.forEach((node) => {
  738. editorCommands.state.nodes[node.id] = node;
  739. });
  740. });
  741. instance.removeSelectedNodes(
  742. instance.getSelectedNodes.value.map(({ id }) => id)
  743. );
  744. instance.removeSelectedEdges(
  745. instance.getSelectedEdges.value.map(({ id }) => id)
  746. );
  747. const convertToObj = (array) =>
  748. array.reduce((acc, item) => {
  749. acc[item.id] = item;
  750. return acc;
  751. }, {});
  752. setTimeout(() => {
  753. const commandInitState = {
  754. nodes: convertToObj(instance.getNodes.value),
  755. edges: convertToObj(instance.getEdges.value),
  756. };
  757. editorCommands = new EditorCommands(instance, commandInitState);
  758. }, 1000);
  759. const { blockId } = route.query;
  760. if (blockId) {
  761. const block = instance.getNode.value(blockId);
  762. if (!block) return;
  763. instance.addSelectedNodes([block]);
  764. setTimeout(() => {
  765. const editorContainer = document.querySelector('.vue-flow');
  766. const { height, width } = editorContainer.getBoundingClientRect();
  767. const { x, y } = block.position;
  768. instance.setTransform({
  769. y: -(y - height / 2),
  770. x: -(x - width / 2) - 200,
  771. zoom: 1,
  772. });
  773. }, 200);
  774. }
  775. }
  776. function clearHighlightedElements() {
  777. const elements = document.querySelectorAll(
  778. '.dropable-area__node, .dropable-area__handle'
  779. );
  780. elements.forEach((element) => {
  781. element.classList.remove('dropable-area__node');
  782. element.classList.remove('dropable-area__handle');
  783. });
  784. }
  785. function toggleHighlightElement({ target, elClass, classes }) {
  786. const targetEl = target.closest(elClass);
  787. if (targetEl) {
  788. targetEl.classList.add(classes);
  789. } else {
  790. const elements = document.querySelectorAll(`.${classes}`);
  791. elements.forEach((element) => {
  792. element.classList.remove(classes);
  793. });
  794. }
  795. }
  796. function onDragoverEditor({ target }) {
  797. toggleHighlightElement({
  798. target,
  799. elClass: '.vue-flow__handle.source',
  800. classes: 'dropable-area__handle',
  801. });
  802. if (!target.closest('.vue-flow__handle')) {
  803. toggleHighlightElement({
  804. target,
  805. elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
  806. classes: 'dropable-area__node',
  807. });
  808. }
  809. }
  810. function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
  811. const block = parseJSON(dataTransfer.getData('block'), null);
  812. if (!block) return;
  813. clearHighlightedElements();
  814. const nodeEl = DroppedNode.isNode(target);
  815. if (nodeEl) {
  816. DroppedNode.replaceNode(editor.value, { block, target: nodeEl });
  817. return;
  818. }
  819. const isTriggerExists =
  820. block.id === 'trigger' &&
  821. editor.value.getNodes.value.some((node) => node.label === 'trigger');
  822. if (isTriggerExists) return;
  823. const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
  824. const nodeId = nanoid();
  825. const newNode = {
  826. position,
  827. label: block.id,
  828. data: block.data,
  829. type: block.component,
  830. id: block.id === 'blocks-group-2' ? `group-${nodeId}` : nodeId,
  831. };
  832. editor.value.addNodes([newNode]);
  833. const edgeEl = DroppedNode.isEdge(target);
  834. const handleEl = DroppedNode.isHandle(target);
  835. if (handleEl) {
  836. DroppedNode.appendNode(editor.value, {
  837. target: handleEl,
  838. nodeId: newNode.id,
  839. });
  840. } else if (edgeEl) {
  841. DroppedNode.insertBetweenNode(editor.value, {
  842. target: edgeEl,
  843. nodeId: newNode.id,
  844. outputs: block.outputs,
  845. });
  846. }
  847. if (block.fromGroup) {
  848. setTimeout(() => {
  849. const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
  850. blockEl?.setAttribute('group-item-id', block.itemId);
  851. }, 200);
  852. }
  853. state.dataChanged = true;
  854. }
  855. function copyElements(nodes, edges, initialPos) {
  856. const newIds = new Map();
  857. let firstNodePos = null;
  858. const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
  859. const newNodeId = nanoid();
  860. const nodePos = {
  861. z: position.z || 0,
  862. y: position.y + 50,
  863. x: position.x + 50,
  864. };
  865. newIds.set(id, newNodeId);
  866. if (initialPos) {
  867. if (index === 0) {
  868. firstNodePos = {
  869. x: nodePos.x,
  870. y: nodePos.y,
  871. };
  872. initialPos = editor.value.project({
  873. y: initialPos.clientY,
  874. x: initialPos.clientX - 360,
  875. });
  876. Object.assign(nodePos, initialPos);
  877. } else {
  878. const xDistance = nodePos.x - firstNodePos.x;
  879. const yDistance = nodePos.y - firstNodePos.y;
  880. nodePos.x = initialPos.x + xDistance;
  881. nodePos.y = initialPos.y + yDistance;
  882. }
  883. }
  884. const copyNode = cloneDeep({
  885. type,
  886. data,
  887. label,
  888. id: newNodeId,
  889. selected: true,
  890. position: nodePos,
  891. });
  892. copyNode.data = reactive(copyNode.data);
  893. return copyNode;
  894. });
  895. const newEdges = edges.reduce(
  896. (acc, { target, targetHandle, source, sourceHandle }) => {
  897. const targetId = newIds.get(target);
  898. const sourceId = newIds.get(source);
  899. if (!targetId || !sourceId) return acc;
  900. const copyEdge = cloneDeep({
  901. selected: true,
  902. target: targetId,
  903. source: sourceId,
  904. id: `edge-${nanoid()}`,
  905. targetHandle: targetHandle.replace(target, targetId),
  906. sourceHandle: sourceHandle.replace(source, sourceId),
  907. });
  908. acc.push(copyEdge);
  909. return acc;
  910. },
  911. []
  912. );
  913. return {
  914. nodes: newNodes,
  915. edges: newEdges,
  916. };
  917. }
  918. function duplicateElements({ nodes, edges }) {
  919. const selectedNodes = editor.value.getSelectedNodes.value;
  920. const selectedEdges = editor.value.getSelectedEdges.value;
  921. const { edges: newEdges, nodes: newNodes } = copyElements(
  922. nodes || selectedNodes,
  923. edges || selectedEdges
  924. );
  925. selectedNodes.forEach((node) => {
  926. node.selected = false;
  927. });
  928. selectedEdges.forEach((edge) => {
  929. edge.selected = false;
  930. });
  931. editor.value.addNodes(newNodes);
  932. editor.value.addEdges(newEdges);
  933. }
  934. function copySelectedElements(data = {}) {
  935. const nodes = data.nodes || editor.value.getSelectedNodes.value;
  936. const edges = data.edges || editor.value.getSelectedEdges.value;
  937. const clipboardData = JSON.stringify({
  938. name: 'automa-blocks',
  939. data: { nodes, edges },
  940. });
  941. navigator.clipboard.writeText(clipboardData).catch((error) => {
  942. console.error(error);
  943. });
  944. }
  945. async function pasteCopiedElements(position) {
  946. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  947. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  948. const permission = await browser.permissions.request({
  949. permissions: ['clipboardRead'],
  950. });
  951. if (!permission) {
  952. toast.error('Automa require clipboard permission to paste blocks');
  953. return;
  954. }
  955. try {
  956. const copiedText = await navigator.clipboard.readText();
  957. const blocks = parseJSON(copiedText);
  958. if (blocks && blocks.name === 'automa-blocks') {
  959. const { nodes, edges } = copyElements(
  960. blocks.data.nodes,
  961. blocks.data.edges,
  962. position
  963. );
  964. editor.value.addNodes(nodes);
  965. editor.value.addEdges(edges);
  966. return;
  967. }
  968. } catch (error) {
  969. console.error(error);
  970. }
  971. }
  972. function undoRedoCommand(type, { target }) {
  973. const els = ['INPUT', 'SELECT', 'TEXTAREA'];
  974. if (els.includes(target.tagName) || target.isContentEditable) return;
  975. executeCommand(type);
  976. }
  977. function onKeydown({ ctrlKey, metaKey, shiftKey, key, target }) {
  978. const els = ['INPUT', 'SELECT', 'TEXTAREA'];
  979. if (els.includes(target.tagName) || target.isContentEditable) return;
  980. const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
  981. if (command('c')) {
  982. copySelectedElements();
  983. } else if (command('v')) {
  984. pasteCopiedElements();
  985. } else if (command('z')) {
  986. undoRedoCommand(shiftKey ? 'redo' : 'undo');
  987. }
  988. }
  989. async function fetchConnectedTable() {
  990. const table = await dbStorage.tablesItems
  991. .where('id')
  992. .equals(workflow.value.connectedTable)
  993. .first();
  994. if (!table) return;
  995. connectedTable.value = table;
  996. }
  997. function checkWorkflowPermission() {
  998. getWorkflowPermissions(workflow.value.drawflow).then((permissions) => {
  999. if (permissions.length === 0) return;
  1000. permissionState.items = permissions;
  1001. permissionState.showModal = true;
  1002. });
  1003. }
  1004. function checkWorkflowUpdate() {
  1005. const updatedAt = encodeURIComponent(workflow.value.updatedAt);
  1006. fetchApi(
  1007. `/teams/${teamId}/workflows/${workflowId}/check-update?updatedAt=${updatedAt}`
  1008. )
  1009. .then((response) => response.json())
  1010. .then((result) => {
  1011. if (!result) return;
  1012. updateWorkflow(result).then(() => {
  1013. editor.value.setNodes(result.drawflow.nodes || []);
  1014. editor.value.setEdges(result.drawflow.edges || []);
  1015. editor.value.fitView();
  1016. });
  1017. })
  1018. .catch((error) => {
  1019. console.error(error);
  1020. });
  1021. }
  1022. const shortcut = useShortcut([
  1023. getShortcut('editor:toggle-sidebar', toggleSidebar),
  1024. getShortcut('editor:duplicate-block', duplicateElements),
  1025. ]);
  1026. watch(
  1027. () => state.activeTab,
  1028. (value) => {
  1029. router.replace({ ...route, query: { tab: value } });
  1030. }
  1031. );
  1032. watch(
  1033. () => route.params.id,
  1034. (value, oldValue) => {
  1035. if (route.name !== 'workflows-details') return;
  1036. if (value && oldValue && value !== oldValue) {
  1037. window.location.reload();
  1038. }
  1039. }
  1040. );
  1041. /* eslint-disable consistent-return */
  1042. onBeforeRouteLeave(() => {
  1043. updateHostedWorkflow();
  1044. if (!state.dataChanged || !haveEditAccess.value) return;
  1045. const confirm = window.confirm(t('message.notSaved'));
  1046. if (!confirm) return false;
  1047. });
  1048. onMounted(() => {
  1049. if (!workflow.value) {
  1050. router.replace('/');
  1051. return null;
  1052. }
  1053. state.showSidebar =
  1054. JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
  1055. const convertedData = convertWorkflowData(workflow.value);
  1056. updateWorkflow({ drawflow: convertedData.drawflow }).then(() => {
  1057. state.workflowConverted = true;
  1058. });
  1059. if (route.query.permission || (isTeamWorkflow && !haveEditAccess.value))
  1060. checkWorkflowPermission();
  1061. if (isTeamWorkflow && !haveEditAccess.value && workflow.value.updatedAt) {
  1062. checkWorkflowUpdate();
  1063. }
  1064. if (workflow.value.connectedTable) {
  1065. fetchConnectedTable();
  1066. }
  1067. initAutocomplete();
  1068. window.onbeforeunload = () => {
  1069. updateHostedWorkflow();
  1070. if (state.dataChanged && haveEditAccess.value) {
  1071. return t('message.notSaved');
  1072. }
  1073. };
  1074. window.addEventListener('keydown', onKeydown);
  1075. });
  1076. onBeforeUnmount(() => {
  1077. window.onbeforeunload = null;
  1078. window.removeEventListener('keydown', onKeydown);
  1079. });
  1080. </script>
  1081. <style>
  1082. .vue-flow,
  1083. .editor-tab {
  1084. width: 100%;
  1085. height: 100%;
  1086. }
  1087. .vue-flow__node {
  1088. @apply rounded-lg;
  1089. }
  1090. .dropable-area__node,
  1091. .dropable-area__handle {
  1092. @apply ring-4;
  1093. }
  1094. .animate-blocks {
  1095. .vue-flow__transformationpane,
  1096. .vue-flow__node {
  1097. transition: transform 300ms ease;
  1098. }
  1099. }
  1100. .undo-redo {
  1101. button:not(:disabled):hover {
  1102. @apply bg-box-transparent;
  1103. }
  1104. button:disabled {
  1105. @apply text-gray-500 dark:text-gray-400;
  1106. }
  1107. }
  1108. </style>